https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day25_video_player_project
我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。
https://www.wongwonggoods.com/python/pyqt5-5/
我們在裡面加入了一些我們需要的元素:
self.button_stop:停止鍵
self.button_play:播放鍵
self.button_pause:暫停鍵
self.button_openfile:開啟檔案鍵
self.label_videoframe:顯示畫面
self.label_framecnt:顯示目前 frame 數/ 全部 frame 數
self.label_filepath:顯示檔案路徑
與之前設計圖片不同的是,我們拿掉了可以捲動的滑條,
我希望能夠強制更改比例以符合視窗 (方便一個視窗就能瀏覽)。
我設計的顯示框為 800x450,等於 16:9,
符合目前最常見的影片比例 1920x1080、1280x720
pyuic5 -x day25.ui -o UI.py
一樣,這程式只有介面 (視覺上的呈現),沒有任何互動功能
python UI.py
我們要設計一個播放器,我們必須要想好播放器的架構可能會有哪幾種 ”state (狀態)“,
我們可以簡單地想一下:
而剛開始載入影片時,我們選擇的狀態是 pause,因為暫停狀態才可以任意變更 frame 值 (後續的應用),
而停止狀態永遠都會回到第一格。
以上大概就是我們設計的 state。
正如同我們前面的文章,這次我們把 img_controller 修改為 video_controller,
並加入類似的功能。
from PyQt5 import QtCore
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtCore import QTimer
from opencv_engine import opencv_engine
# videoplayer_state_dict = {
# "stop":0,
# "play":1,
# "pause":2
# }
class video_controller(object):
def __init__(self, video_path, ui):
self.video_path = video_path
self.ui = ui
self.qpixmap_fix_width = 800 # 16x9 = 1920x1080 = 1280x720 = 800x450
self.qpixmap_fix_height = 450
self.current_frame_no = 0
self.videoplayer_state = "stop"
self.init_video_info()
self.set_video_player()
def init_video_info(self):
videoinfo = opencv_engine.getvideoinfo(self.video_path)
self.vc = videoinfo["vc"]
self.video_fps = videoinfo["fps"]
self.video_total_frame_count = videoinfo["frame_count"]
self.video_width = videoinfo["width"]
self.video_height = videoinfo["height"]
def set_video_player(self):
self.timer=QTimer() # init QTimer
self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
# self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)
def __get_frame_from_frame_no(self, frame_no):
self.vc.set(1, frame_no)
ret, frame = self.vc.read()
self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
return frame
def __update_label_frame(self, frame):
bytesPerline = 3 * self.video_width
qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
self.qpixmap = QPixmap.fromImage(qimg)
if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
else: # like 1600/16 < 9000/9, width is shorter, align height
self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
self.ui.label_videoframe.setPixmap(self.qpixmap)
# self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center
def play(self):
self.videoplayer_state = "play"
def stop(self):
self.videoplayer_state = "stop"
def pause(self):
self.videoplayer_state = "pause"
def timer_timeout_job(self):
frame = self.__get_frame_from_frame_no(self.current_frame_no)
self.__update_label_frame(frame)
if (self.videoplayer_state == "play"):
self.current_frame_no += 1
if (self.videoplayer_state == "stop"):
self.current_frame_no = 0
if (self.videoplayer_state == "pause"):
self.current_frame_no = self.current_frame_no
我們開始來慢慢解釋這些東西。
這邊我們使用 Qtimer,原因很簡單,每支影片都有他自己的 fps,
我們透過計算可以得到「我們應該每多少毫秒,就該換下一個 frame 顯示」。
我們用 frame number 來管理現在要顯示哪一個畫面,
而控制 frame number 的就是我們目前 state 的狀態,以每一個 QTimer timeout 的頻率更新。
def set_video_player(self):
self.timer=QTimer() # init QTimer
self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
# self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)
def timer_timeout_job(self):
frame = self.__get_frame_from_frame_no(self.current_frame_no)
self.__update_label_frame(frame)
if (self.videoplayer_state == "play"):
self.current_frame_no += 1
if (self.videoplayer_state == "stop"):
self.current_frame_no = 0
if (self.videoplayer_state == "pause"):
self.current_frame_no = self.current_frame_no
但這邊我們在執行後才發現我們雖然邏輯正確,但想得太美了
OpenCV 在 decode 所需要花的時間大於我們想要控制的顯示時間,
(簡單來說,decode 太久,導致沒辦法在依照我們想要的 fps 播放)
所以我先暫時改成 self.timer.start(1),讓我們只休息 1ms,
但畢竟 QT 是以 multithread 在進行操作,
這段優化的空間可能要改以 multiprocess 進行才能夠讓我們影片順暢的播放 (這個比較不是此系列重點,有空我們再來實作)
def play(self):
self.videoplayer_state = "play"
def stop(self):
self.videoplayer_state = "stop"
def pause(self):
self.videoplayer_state = "pause"
這邊我使用的邏輯,就是讓按鍵會直接更改到 state 的狀態,
而 state 會去控制現在視窗要顯示的 frame
從上面應該可以理解一些小細節,我們用 frame number 來管理我們要顯示的 frame,
而我們透過 frame number 取得 frame 影像的機制,我們寫在 __get_frame_from_frame_no() 當中。
而取得介面後,並更新於 UI 介面的機制,我們寫在 __update_label_frame() 當中。
這邊也有個小細節,我們會自動以 16:9 為基準去看讀入影片的比例,
def __get_frame_from_frame_no(self, frame_no):
self.vc.set(1, frame_no)
ret, frame = self.vc.read()
self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
return frame
def __update_label_frame(self, frame):
bytesPerline = 3 * self.video_width
qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
self.qpixmap = QPixmap.fromImage(qimg)
if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
else: # like 1600/16 < 9000/9, width is shorter, align height
self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
self.ui.label_videoframe.setPixmap(self.qpixmap)
# self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center
相信有閱讀之前文章的讀者應該都不陌生,
而這邊我們要透過 opencv 協助我們完成影片的讀取,並分析一些資訊。
程式被呼叫的地方在 video_controller 的 init_video_info,
我們把所有必要的影片資訊封裝成一個 dict 回傳。
def init_video_info(self):
videoinfo = opencv_engine.getvideoinfo(self.video_path)
self.vc = videoinfo["vc"]
self.video_fps = videoinfo["fps"]
self.video_total_frame_count = videoinfo["frame_count"]
self.video_width = videoinfo["width"]
self.video_height = videoinfo["height"]
所以我們在 opencv_engine.py 實作一個新的方法。
@staticmethod
def getvideoinfo(video_path):
# https://docs.opencv.org/4.5.3/dc/d3d/videoio_8hpp.html
videoinfo = {}
vc = cv2.VideoCapture(video_path)
videoinfo["vc"] = vc
videoinfo["fps"] = vc.get(cv2.CAP_PROP_FPS)
videoinfo["frame_count"] = int(vc.get(cv2.CAP_PROP_FRAME_COUNT))
videoinfo["width"] = int(vc.get(cv2.CAP_PROP_FRAME_WIDTH))
videoinfo["height"] = int(vc.get(cv2.CAP_PROP_FRAME_HEIGHT))
return videoinfo
把一些我們感興趣的資訊都存進 videoinfo 裡面,並回傳。
def setup_control(self):
self.ui.button_openfile.clicked.connect(self.open_file)
def open_file(self):
filename, filetype = QFileDialog.getOpenFileName(self, "Open file Window", "./", "Video Files(*.mp4 *.avi)") # start path
self.video_path = filename
self.video_controller = video_controller(video_path=self.video_path,
ui=self.ui)
self.ui.label_filepath.setText(f"video path: {self.video_path}")
self.ui.button_play.clicked.connect(self.video_controller.play) # connect to function()
self.ui.button_stop.clicked.connect(self.video_controller.stop)
self.ui.button_pause.clicked.connect(self.video_controller.pause)
我們先讓開啟檔案按鍵的功能連結起來,
開檔成功之後,才綁定按鍵的功能,這些功能定義在 video_controller 中。
我們預期所有的按鍵行為應該是在「開檔後」才會執行 (例如:沒讀取影片,沒必要讓「播放」有功能。)
我們目前有一個很 lag 的 video player,
原因可能是因為 decode 速度不夠快,可能可以透過 multiprocess 優化。
後續:後來有找到原因,為 vc.set 反覆執行會吃掉大量程式效率,之後文章會再分享該如何修正
★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 25 project / 自己做一個影片播放器 DIY video player (結合 PyQt + OpenCV)